Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cd115a9
feat(auth): add ZAI_PLATFORM_ID constant
elkaix Jun 3, 2026
9bb6b23
feat(auth/z-ai): add ZaiModel, hardcoded model list, env key getter
elkaix Jun 3, 2026
1aa2d3b
feat(auth/z-ai): add _apply_z_ai_config
elkaix Jun 3, 2026
633179d
feat(auth/z-ai): add model discovery helpers and _parse_discovered_mo…
elkaix Jun 3, 2026
544e196
feat(auth/z-ai): add apply_z_ai_models and refresh_z_ai_models
elkaix Jun 3, 2026
4f4ce31
feat(auth/z-ai): add login_z_ai_api_key and logout_z_ai
elkaix Jun 3, 2026
86b4169
feat(auth/platforms): wire Z AI into refresh_managed_models
elkaix Jun 3, 2026
0f23b02
feat(ui/oauth): add Z AI login/logout selector and mode branches
elkaix Jun 3, 2026
cb0842c
style(auth/z-ai): fix ruff lint and format
elkaix Jun 3, 2026
0412c84
fix(auth/z-ai): correct /logout usage typo and format test file
elkaix Jun 3, 2026
75f3c33
feat(stats): add model pricing table for /stats cost computation
elkaix Jun 4, 2026
3a77d23
feat(stats): add model_name/provider_key to StatusUpdate wire event
elkaix Jun 4, 2026
a922b95
feat(stats): add session data collector and period aggregator
elkaix Jun 4, 2026
3f53a48
feat(stats): add /stats TUI dashboard with provider/model breakdown
elkaix Jun 4, 2026
b6d7286
feat(stats): wire up insights rendering and AllStats return type
elkaix Jun 4, 2026
4d3e809
fix(stats): resolve pyright strict-mode and ruff lint errors
elkaix Jun 4, 2026
b400a98
chore: update CHANGELOG for /stats dashboard and Z AI provider
elkaix Jun 4, 2026
4f344bb
fix(stats): remove unused imports from test_stats_collector
elkaix Jun 4, 2026
8ea8b52
feat(auth/z-ai): add MOONSHOT_PLATFORM_ID, pin thinking defaults, kim…
elkaix Jun 4, 2026
cb52f4c
fix(cr-75): address CodeRabbit review findings
elkaix Jun 4, 2026
66d9410
fix(e2e+stats): update StatusUpdate snapshots for new fields, fix sub…
elkaix Jun 4, 2026
0cf404d
fix(cr-75-r2): address second-pass CodeRabbit findings
elkaix Jun 4, 2026
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ GitHub Releases page; `0.8.0` is the new starting line.

## Unreleased

- **`/stats` usage dashboard.** New slash command opens an interactive prompt_toolkit TUI showing token and cost breakdown by provider/model across Today / This Week / Last Week / All Time. Powered by a static pricing table (`get_cost_usd`) and a session collector that walks `~/.pythinker/sessions/` wire files. `StatusUpdate` wire events now carry optional `model_name` / `provider_key` fields for per-step attribution.
- **Z AI provider auth.** Login/logout via API key, model discovery, and OAuth selector wired into the TUI and `refresh_managed_models`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **Moonshot provider auth.** Login/logout via API key, model discovery (Kimi K2.x catalog), OAuth selector wired into the TUI and `refresh_managed_models`.

## 0.33.0 (2026-06-03)

### What changed in this release
Expand Down
4 changes: 4 additions & 0 deletions src/pythinker_code/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,25 @@
OPENAI_CHATGPT_PLATFORM_ID = "openai-chatgpt"
OPENCODE_GO_PLATFORM_ID = "opencode-go"
MINIMAX_PLATFORM_ID = "minimax"
MOONSHOT_PLATFORM_ID = "moonshot"
DEEPSEEK_PLATFORM_ID = "deepseek"
ANTHROPIC_PLATFORM_ID = "anthropic"
OPENROUTER_PLATFORM_ID = "openrouter"
LM_STUDIO_PLATFORM_ID = "lm-studio"
OLLAMA_PLATFORM_ID = "ollama"
ZAI_PLATFORM_ID = "z-ai"

__all__ = [
"ANTHROPIC_PLATFORM_ID",
"DEEPSEEK_PLATFORM_ID",
"LM_STUDIO_PLATFORM_ID",
"MINIMAX_PLATFORM_ID",
"MOONSHOT_PLATFORM_ID",
"OLLAMA_PLATFORM_ID",
"OPENAI_API_PLATFORM_ID",
"OPENAI_CHATGPT_PLATFORM_ID",
"OPENCODE_GO_PLATFORM_ID",
"OPENROUTER_PLATFORM_ID",
"PYTHINKER_CODE_PLATFORM_ID",
"ZAI_PLATFORM_ID",
]
198 changes: 198 additions & 0 deletions src/pythinker_code/auth/moonshot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
from __future__ import annotations

import os
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any, cast

import aiohttp
from pydantic import SecretStr

from pythinker_code.auth import MOONSHOT_PLATFORM_ID
from pythinker_code.auth.oauth import OAuthEvent
from pythinker_code.auth.platforms import managed_model_key, managed_provider_key
from pythinker_code.config import Config, LLMModel, LLMProvider, save_config
from pythinker_code.thinking import apply_login_thinking_defaults
from pythinker_code.utils.aiohttp import new_client_session

MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"
MOONSHOT_PROVIDER_KEY = managed_provider_key(MOONSHOT_PLATFORM_ID)
MOONSHOT_DEFAULT_MODEL_ALIAS = managed_model_key(MOONSHOT_PLATFORM_ID, "kimi-k2.6")


@dataclass(frozen=True, slots=True)
class MoonshotModel:
model_id: str
alias_suffix: str
display_name: str
provider_key: str = MOONSHOT_PROVIDER_KEY
max_context_size: int = 262_144

@property
def alias(self) -> str:
return f"{MOONSHOT_PLATFORM_ID}/{self.alias_suffix}"


MOONSHOT_MODELS: tuple[MoonshotModel, ...] = (
MoonshotModel("kimi-k2.6", "kimi-k2.6", "Kimi K2.6"),
MoonshotModel("kimi-k2.5", "kimi-k2.5", "Kimi K2.5"),
MoonshotModel("kimi-k2-thinking", "kimi-k2-thinking", "Kimi K2 Thinking"),
)


def get_moonshot_api_key_from_env() -> str | None:
raw = os.environ.get("MOONSHOT_API_KEY", "")
if not raw:
return None
return raw.strip()


def _apply_moonshot_config(
config: Config,
api_key: SecretStr,
models: tuple[MoonshotModel, ...] = MOONSHOT_MODELS,
) -> None:
config.providers[MOONSHOT_PROVIDER_KEY] = LLMProvider(
type="openai_legacy",
base_url=MOONSHOT_BASE_URL,
api_key=api_key,
)

provider_keys = {MOONSHOT_PROVIDER_KEY}
for key, model in list(config.models.items()):
if model.provider in provider_keys:
del config.models[key]

for model in models:
config.models[model.alias] = LLMModel(
provider=model.provider_key,
model=model.model_id,
max_context_size=model.max_context_size,
display_name=model.display_name,
)

fallback = next(
(m.alias for m in models),
next(iter(config.models), ""),
)
if MOONSHOT_DEFAULT_MODEL_ALIAS in config.models:
config.default_model = MOONSHOT_DEFAULT_MODEL_ALIAS
else:
config.default_model = fallback
apply_login_thinking_defaults(config, thinking=False, effort="off")


def _model_by_id() -> dict[str, MoonshotModel]:
return {model.model_id: model for model in MOONSHOT_MODELS}


def _parse_discovered_models(data: object) -> tuple[MoonshotModel, ...] | None:
"""Return parsed models, or None if the payload is structurally invalid."""
if not isinstance(data, dict):
return None
d = cast(dict[str, Any], data)
items = d.get("data")
if not isinstance(items, list):
return None
catalog = _model_by_id()
results: list[MoonshotModel] = []
for raw_item in items: # pyright: ignore[reportUnknownVariableType]
if not isinstance(raw_item, dict):
continue
item = cast(dict[str, Any], raw_item)
model_id = item.get("id")
if not isinstance(model_id, str) or model_id not in catalog:
continue
base = catalog[model_id]
ctx = item.get("context_length")
max_ctx = base.max_context_size
if isinstance(ctx, int) and ctx > 0:
max_ctx = ctx
display_name = base.display_name
api_name = item.get("display_name")
if isinstance(api_name, str) and api_name:
display_name = api_name
results.append(
MoonshotModel(
model_id=base.model_id,
alias_suffix=base.alias_suffix,
display_name=display_name,
provider_key=base.provider_key,
max_context_size=max_ctx,
)
)
return tuple(results)


async def _discover_moonshot_models(
api_key: str,
) -> tuple[MoonshotModel, ...] | None:
async with (
new_client_session() as session,
session.get(
f"{MOONSHOT_BASE_URL}/models",
headers={"Authorization": f"Bearer {api_key}"},
) as resp,
):
resp.raise_for_status()
payload: object = await resp.json()
return _parse_discovered_models(payload)


async def login_moonshot_api_key(
config: Config, api_key: str | None = None
) -> AsyncIterator[OAuthEvent]:
if not config.is_from_default_location:
yield OAuthEvent(
"error",
"Login requires the default config file; restart without --config/--config-file.",
)
return

resolved_key = (api_key or get_moonshot_api_key_from_env() or "").strip()
if not resolved_key:
yield OAuthEvent("error", "Moonshot API key is required.")
return

models = MOONSHOT_MODELS
try:
discovered = await _discover_moonshot_models(resolved_key)
if discovered is not None and discovered:
models = discovered
except aiohttp.ClientResponseError as exc:
if exc.status in {401, 403}:
yield OAuthEvent("error", "Invalid Moonshot API key; the key was not saved.")
return
yield OAuthEvent(
"info",
"Moonshot model listing is unavailable; using the built-in model list.",
)
except (aiohttp.ClientError, TimeoutError, ValueError):
yield OAuthEvent(
"info",
"Moonshot model listing is unavailable; using the built-in model list.",
)

_apply_moonshot_config(config, SecretStr(resolved_key), models=models)
save_config(config)
yield OAuthEvent("success", f"Moonshot configured with model {config.default_model}.")


async def logout_moonshot(config: Config) -> AsyncIterator[OAuthEvent]:
if not config.is_from_default_location:
yield OAuthEvent(
"error",
"Logout requires the default config file; restart without --config/--config-file.",
)
return

provider_keys = {MOONSHOT_PROVIDER_KEY}
config.providers.pop(MOONSHOT_PROVIDER_KEY, None)
for key, model in list(config.models.items()):
if model.provider in provider_keys:
del config.models[key]

if config.default_model not in config.models:
config.default_model = next(iter(config.models), "")
save_config(config)
yield OAuthEvent("success", "Logged out of Moonshot successfully.")
22 changes: 19 additions & 3 deletions src/pythinker_code/auth/platforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,12 @@ async def refresh_managed_models(config: Config) -> bool:
apply_opencode_go_models,
refresh_opencode_go_models,
)
from pythinker_code.auth.z_ai import (
ZAI_PROVIDER_KEY,
ZaiModel,
apply_z_ai_models,
refresh_z_ai_models,
)

managed_providers = {
key: provider for key, provider in config.providers.items() if is_managed_provider_key(key)
Expand All @@ -258,9 +264,9 @@ async def refresh_managed_models(config: Config) -> bool:
# generic `managed:<platform>` path can't express OpenCode Go's
# two-provider split, and MiniMax's provider key intentionally includes
# the wire-shape suffix (`managed:minimax-anthropic`).
if (
provider_key in OPENCODE_GO_PROVIDER_KEYS
or provider_key == MINIMAX_ANTHROPIC_PROVIDER_KEY
if provider_key in OPENCODE_GO_PROVIDER_KEYS or provider_key in (
MINIMAX_ANTHROPIC_PROVIDER_KEY,
ZAI_PROVIDER_KEY,
):
continue
platform_id = parse_managed_provider_key(provider_key)
Expand Down Expand Up @@ -417,6 +423,14 @@ async def refresh_managed_models(config: Config) -> bool:
if minimax_models is not None and apply_minimax_models(config, minimax_models):
changed = True

z_ai_models: tuple[ZaiModel, ...] | None = None
try:
z_ai_models = await refresh_z_ai_models(config)
except (aiohttp.ClientError, TimeoutError, ValueError) as exc:
logger.warning("Failed to refresh Z AI models: {error}", error=exc)
if z_ai_models is not None and apply_z_ai_models(config, z_ai_models):
changed = True

if changed:
config_for_save = load_config()
save_changed = False
Expand All @@ -427,6 +441,8 @@ async def refresh_managed_models(config: Config) -> bool:
save_changed = True
if minimax_models is not None and apply_minimax_models(config_for_save, minimax_models):
save_changed = True
if z_ai_models is not None and apply_z_ai_models(config_for_save, z_ai_models):
save_changed = True
if save_changed:
save_config(config_for_save)
return changed
Expand Down
Loading
Loading