Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ Thumbs.db
!tests/test_updater_write_guard.py
!tests/test_updater_orchestrator.py
!tests/test_usage_ledger.py
!tests/test_pricing.py
!tests/smoke_test_costs.py

# Mermaid regression tests and development-only parser harness
!tests/test_mermaid_validation.py
Expand Down
65 changes: 61 additions & 4 deletions codewiki/cli/adapters/doc_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ def generate(self) -> DocumentationJob:
agent_instructions=self.config.get("agent_instructions"),
use_gitignore=self.config.get("use_gitignore", True),
prompt_caching=self.config.get("prompt_caching", True),
pricing=self.config.get("pricing", {}),
artifacts_enabled=self.config.get("artifacts_enabled", True),
artifact_token_budget=self.config.get("artifact_token_budget", 200_000),
with_prose=self.config.get("with_prose", False),
Expand All @@ -181,15 +182,18 @@ def generate(self) -> DocumentationJob:
self._finalize_job()

# Complete job
self._finish_usage()
self.job.complete()

return self.job

except APIError as e:
self.job.fail(str(e))
self._finish_usage(incomplete=True)
raise
except Exception as e:
self.job.fail(str(e))
self._finish_usage(incomplete=True)
raise

async def _run_backend_generation(self, backend_config: BackendConfig):
Expand Down Expand Up @@ -448,6 +452,7 @@ async def _run_incremental_update(
updater = IncrementalUpdater(
backend_config, doc_generator.backend, doc_generator, update_opts
)
self._last_update_record = updater.record
with doc_generator.usage_ledger.in_phase("update"):
record = await updater.run(prev_graph_path, components, leaf_nodes, revision)
self._last_update_record = record
Expand Down Expand Up @@ -484,6 +489,8 @@ def _move_docs_aside(self) -> None:
"""Keep the previous docs (and the failed attempt's record) next to the output dir."""
import shutil

self._prior_update_history = self._read_update_history(str(self.output_dir))

suffix = (self.commit_id or "unknown")[:8]
target = self.output_dir.parent / f"{self.output_dir.name}.prev-{suffix}"
n = 2
Expand Down Expand Up @@ -515,11 +522,17 @@ def _merge_update_summary(self, working_dir: str, prior_history: list | None = N
return
from codewiki.src.be.updater.record import merge_into_metadata

summary = record.summary()
preserved = getattr(self, "_preserved_docs_dir", None)
if self._usage_ledger is not None:
record.usage_report = self._usage_ledger.summary()
summary = record.summary()
if preserved:
summary["previous_docs"] = preserved
merge_into_metadata(working_dir, summary, prior_history=prior_history)
merge_into_metadata(
working_dir,
summary,
prior_history=prior_history or getattr(self, "_prior_update_history", None),
)
try:
record.save(working_dir)
except OSError:
Expand Down Expand Up @@ -563,22 +576,66 @@ def _record_usage_statistics(self):
summary = self._usage_ledger.summary()
totals = summary["totals"]
stats = self.job.statistics
stats.usage_report = summary
stats.usage_totals = totals
stats.total_tokens_used = int(
sum(
totals.get(k, 0)
for k in (
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
)
)
or totals.get("total_tokens", 0)
)
stats.total_cost_usd = totals.get("cost_usd")
stats.unpriced_models = summary["unpriced_models"]

def _finish_usage(self, incomplete: bool = False):
"""Persist the final report even when validation or a later stage fails.

Reporting errors must never hide the original generation failure.
"""
if self._usage_ledger is None:
return
self._usage_ledger.incomplete = incomplete
self._record_usage_statistics()
report = self.job.statistics.usage_report
try:
self.output_dir.mkdir(parents=True, exist_ok=True)
path = self.output_dir / "metadata.json"
try:
metadata = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
metadata = {}
if not isinstance(metadata, dict):
metadata = {}
metadata["usage"] = report
record = getattr(self, "_last_update_record", None)
if record is not None:
record.usage_report = report
record.save(str(self.output_dir))
summary = record.summary()
if getattr(self, "_preserved_docs_dir", None):
summary["previous_docs"] = self._preserved_docs_dir
history = metadata.get("update_history", [])
if not isinstance(history, list):
history = []
if history and history[-1].get("run_id") == record.run_id:
history[-1] = summary
else:
history.append(summary)
metadata.update(last_update=summary, update_history=history)
from codewiki.cli.utils.fs import safe_write

safe_write(path, json.dumps(metadata, indent=2, ensure_ascii=False))
except Exception:
logging.getLogger(__name__).warning("Could not save the run usage report")
if incomplete:
from codewiki.cli.utils.instructions import _echo_usage

_echo_usage({"usage_report": report, "provider": self.config.get("provider", "")})

def _finalize_job(self):
"""Finalize the job (metadata already created by backend)."""
# Just verify metadata exists
Expand Down
108 changes: 108 additions & 0 deletions codewiki/cli/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,110 @@ def config_group():
pass


@config_group.group(name="pricing")
def pricing_group():
"""Manage API prices in USD per million tokens."""


def _pricing_manager():
manager = ConfigManager()
try:
manager.load()
except ConfigurationError as exc:
raise click.ClickException(str(exc)) from exc
return manager


def _pricing_provider(manager, provider):
provider = provider or (
manager.get_config().provider if manager.get_config() else "openai-compatible"
)
supported = {
"openai-compatible",
"atlas-cloud",
"anthropic",
"bedrock",
"azure-openai",
"google",
"google-vertex",
}
if provider not in supported:
raise click.BadParameter(
"Choose an API provider; Claude Code and Codex use their provider cost results.",
param_hint="--provider",
)
return provider


@pricing_group.command(name="set")
@click.argument("model")
@click.option(
"--input", "input_rate", required=True, type=float, help="USD per million input tokens."
)
@click.option(
"--output", "output_rate", required=True, type=float, help="USD per million output tokens."
)
@click.option("--cache-read", type=float, help="Defaults to the input rate.")
@click.option("--cache-write", type=float, help="Defaults to the input rate.")
@click.option("--provider", help="Defaults to the active API provider.")
def pricing_set(model, input_rate, output_rate, cache_read, cache_write, provider):
"""Set rates for MODEL. Explicit zero rates are supported."""
from copy import deepcopy
from codewiki.src.be.pricing import validate_pricing

manager = _pricing_manager()
provider = _pricing_provider(manager, provider)
pricing = deepcopy(manager.get_config().pricing) if manager.get_config() else {}
rates = {"input": input_rate, "output": output_rate}
if cache_read is not None:
rates["cache_read"] = cache_read
if cache_write is not None:
rates["cache_write"] = cache_write
pricing.setdefault(provider, {})[model] = rates
try:
validate_pricing(pricing)
manager.save(pricing=pricing)
except (ValueError, ConfigurationError) as exc:
raise click.ClickException(str(exc)) from exc
click.echo(f"API pricing saved for {provider}/{model} (USD per million tokens).")


@pricing_group.command(name="show")
@click.option("--json", "output_json", is_flag=True)
def pricing_show(output_json):
"""Show configured rates for all API providers."""
manager = _pricing_manager()
pricing = manager.get_config().pricing if manager.get_config() else {}
if output_json:
click.echo(json.dumps(pricing, indent=2, sort_keys=True))
elif not pricing:
click.echo("No API prices configured. Use 'codewiki config pricing set --help'.")
else:
click.echo("API pricing (USD per million tokens; omitted cache rates use input):")
for provider, models in sorted(pricing.items()):
for model, rates in sorted(models.items()):
click.echo(f" {provider}/{model}: {json.dumps(rates, sort_keys=True)}")


@pricing_group.command(name="remove")
@click.argument("model")
@click.option("--provider", help="Defaults to the active API provider.")
def pricing_remove(model, provider):
"""Remove configured rates for MODEL."""
from copy import deepcopy

manager = _pricing_manager()
provider = _pricing_provider(manager, provider)
pricing = deepcopy(manager.get_config().pricing) if manager.get_config() else {}
if model not in pricing.get(provider, {}):
raise click.ClickException(f"No price configured for {provider}/{model}")
del pricing[provider][model]
if not pricing[provider]:
del pricing[provider]
manager.save(pricing=pricing)
click.echo(f"API pricing removed for {provider}/{model}.")


@config_group.command(name="set")
@click.option("--api-key", type=str, help="LLM API key (stored securely in system keychain)")
@click.option("--base-url", type=str, help="LLM API base URL (e.g., https://api.anthropic.com)")
Expand Down Expand Up @@ -473,6 +577,7 @@ def config_show(output_json: bool):
"agent_retries": config.agent_retries if config else 3,
"use_gitignore": config.use_gitignore if config else True,
"prompt_caching": config.prompt_caching if config else True,
"pricing": config.pricing if config else {},
"agent_instructions": config.agent_instructions.to_dict()
if config and config.agent_instructions
else {},
Expand Down Expand Up @@ -542,6 +647,9 @@ def config_show(output_json: bool):
click.echo(f" Max Token/Module: {config.max_token_per_module}")
click.echo(f" Max Token/Leaf Module: {config.max_token_per_leaf_module}")
click.echo(f" Prompt Caching: {config.prompt_caching}")
click.echo(
f" API Pricing: {json.dumps(config.pricing, sort_keys=True)}"
)

click.echo()
click.secho("Decomposition Settings", fg="cyan", bold=True)
Expand Down
7 changes: 3 additions & 4 deletions codewiki/cli/commands/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,10 +633,7 @@ def generate_command(
repo_path, output_dir, logger, verbose, compare_to=compare_to
)
if changed_files is not None and len(changed_files) == 0:
logger.success(
"No changes detected since last generation. Documentation is up to date."
)
sys.exit(EXIT_SUCCESS)
logger.info("No committed changes detected; checking documentation state.")
if changed_files is not None and update_rung == "0":
logger.info(
f" Detected {len(changed_files)} changed files — regenerating affected modules."
Expand Down Expand Up @@ -792,6 +789,7 @@ def generate_command(
"base_url": config.base_url,
"api_key": api_key,
"provider": getattr(config, "provider", "openai-compatible"),
"pricing": getattr(config, "pricing", {}),
"aws_region": getattr(config, "aws_region", "us-east-1"),
"google_project": getattr(config, "google_project", ""),
"google_location": getattr(config, "google_location", ""),
Expand Down Expand Up @@ -878,6 +876,7 @@ def generate_command(
"total_files_analyzed": job.statistics.total_files_analyzed,
"generation_time": generation_time,
"total_tokens_used": job.statistics.total_tokens_used,
"usage_report": job.statistics.usage_report,
"usage_totals": job.statistics.usage_totals,
"total_cost_usd": job.statistics.total_cost_usd,
"unpriced_models": job.statistics.unpriced_models,
Expand Down
9 changes: 9 additions & 0 deletions codewiki/cli/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def save(
google_location: Optional[str] = None,
use_gitignore: Optional[bool] = None,
prompt_caching: Optional[bool] = None,
pricing: Optional[dict] = None,
):
"""
Save configuration to file and keyring.
Expand Down Expand Up @@ -199,6 +200,14 @@ def save(
was_google = is_google_provider(self._config.provider)

# Update fields if provided
if pricing is not None:
from codewiki.src.be.pricing import validate_pricing

try:
validate_pricing(pricing)
except ValueError as exc:
raise ConfigurationError(str(exc)) from exc
self._config.pricing = pricing
if base_url is not None:
self._config.base_url = base_url
if main_model is not None:
Expand Down
13 changes: 13 additions & 0 deletions codewiki/cli/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,18 @@ class Configuration:
agent_retries: int = 3
use_gitignore: bool = True
prompt_caching: bool = True
pricing: dict = field(default_factory=dict)
agent_instructions: AgentInstructions = field(default_factory=AgentInstructions)

def __post_init__(self):
from codewiki.src.be.pricing import validate_pricing
from codewiki.cli.utils.errors import ConfigurationError

try:
validate_pricing(self.pricing)
except ValueError as exc:
raise ConfigurationError(str(exc)) from exc

def validate(self):
"""
Validate all configuration fields.
Expand Down Expand Up @@ -227,6 +237,7 @@ def to_dict(self) -> dict:
"agent_retries": self.agent_retries,
"use_gitignore": self.use_gitignore,
"prompt_caching": self.prompt_caching,
"pricing": self.pricing,
"fallback_model": self.fallback_model,
}
if self.agent_instructions and not self.agent_instructions.is_empty():
Expand Down Expand Up @@ -268,6 +279,7 @@ def from_dict(cls, data: dict) -> "Configuration":
agent_retries=data.get("agent_retries", 3),
use_gitignore=data.get("use_gitignore", True),
prompt_caching=data.get("prompt_caching", True),
pricing=data.get("pricing", {}),
agent_instructions=agent_instructions,
)

Expand Down Expand Up @@ -338,4 +350,5 @@ def to_backend_config(
agent_instructions=final_instructions.to_dict() if final_instructions else None,
use_gitignore=self.use_gitignore,
prompt_caching=self.prompt_caching,
pricing=self.pricing,
)
1 change: 1 addition & 0 deletions codewiki/cli/models/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class JobStatistics:
total_cost_usd: Optional[float] = None
# Models whose calls reported tokens but no price
unpriced_models: List[str] = field(default_factory=list)
usage_report: Dict[str, Any] = field(default_factory=dict)


@dataclass
Expand Down
Loading
Loading